{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "8fdbefec-83b6-44d9-87ca-b27d6923ca9b",
   "metadata": {},
   "source": [
    "# **Encapsulation**\n",
    "\n",
    "Encapsulation is one of the four core principles of object-oriented programming (OOP). Encapsulation means **hiding internal data** from the outside world and only **allowing controlled access** via methods or interfaces.\n",
    "\n",
    "**Important Note: In Python, encapsulation is by convention, not by enforcement.**\n",
    "\n",
    "\n",
    "## **Why is it useful?**\n",
    "1. Prevents unintended modification of data.\n",
    "2. Allows validation before updating data.\n",
    "3. Maintains integrity and security of the object's state.\n",
    "\n",
    "## **How to Encapsulate in Python?**\n",
    "In Python, we **prefix variables with an underscore** `_` to signal they are **\"private\"** (by convention). Python **doesn't enforce** strict access, but **developers are expected to respect it**.\n",
    "```python\n",
    "class Student:\n",
    "    def __init__(self, age):\n",
    "        self._age = age  # protected attribute\n",
    "```\n",
    "**But this alone doesn’t prevent wrong inputs!**\n",
    "\n",
    "## **Use of single underscore vs double underscore**\n",
    "The idea of the double underscore in Python is completely different. It was created as a means to override different methods of a class that is going to be extended several times, without the risk of having collisions with the method names. Even that is a too far-fetched use case as to justify the use of this mechanism.\n",
    "\n",
    "Double underscores are a non-Pythonic approach. If we need to define attributes as private, use a single underscore, and respect the Pythonic convention that it is a private attribute.\n",
    "\n",
    "Note that:\n",
    "- Using __double_underscore triggers name mangling — not true privacy.\n",
    "- But using _single_underscore is considered \"Pythonic\" — it indicates something is private, not enforces it.\n",
    "\n",
    "## **Private vs Name Mangling vs Magic Methods**\n",
    "\n",
    "| Symbol     | Purpose                                                        |\n",
    "| ---------- | -------------------------------------------------------------- |\n",
    "| `_name`    | You want to mark an attribute as private                       |\n",
    "| `__name`   | Name Mangling: Avoid name clashes in inheritance.              |\n",
    "| `__name__` | Special/Magic methods (e.g., `__init__`, `__str__`) i.e. dunder|\n",
    "\n",
    "## **Name Mangling**\n",
    "When you name an attribute like **__my_var** inside a class, Python internally changes its name to **_ClassName__my_var**.\n",
    "\n",
    "This is called name mangling, and it's used to prevent accidental overrides when classes are extended (i.e., inheritance)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "7eb84aab-2852-4f10-a85f-daa429bdb1b9",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "-20\n"
     ]
    }
   ],
   "source": [
    "class Student:\n",
    "    def __init__(self, age):\n",
    "        self._age = age  # private attribute\n",
    "\n",
    "s = Student(-20)\n",
    "\n",
    "print(s._age) # Python doesn't enforce strict access"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "921b2907-138c-4a59-b53f-234189493883",
   "metadata": {},
   "source": [
    "## **Getters and Setters - @property and @attr.setter Decorator**\n",
    "- In OOP, objects are created to model real-world things.\n",
    "- These objects often contain data (like attributes) and behaviors (like methods).\n",
    "- Data accuracy/validity is critical — e.g., age should not be negative, email should be properly formatted.\n",
    "- So we write validation logic, especially in setters.\n",
    "- In Python, instead of writing separate get_x() and set_x() methods like in Java, we use @property — a cleaner, Pythonic way to handle this.\n",
    "- Keeps the interface clean (like obj.age) instead of calling obj.get_age().\n",
    "\n",
    "\n",
    "#### **What is @property?**  \n",
    "The @property decorator in Python is used to define a method as a getter for a class attribute, allowing you to access it like a regular attribute while still including logic or validation behind the scenes. It is part of Python’s way of supporting encapsulation in an elegant and Pythonic way.\n",
    "\n",
    "@property makes a method look like an attribute. @property is mandatory before using @attr.setter.\n",
    "\n",
    "| Decorator      | Purpose                                   | Mandatory?                     |\n",
    "| -------------- | ----------------------------------------- | ------------------------------ |\n",
    "| `@property`    | Turns a method into a **getter** property | Yes                          |\n",
    "| `@attr.setter` | Adds a **setter** to the property `attr`  | Yes — only after `@property` |\n",
    "\n",
    "#### **Why is @property mandatory before @attr.setter?**  \n",
    "- In Python, the @attr.setter decorator is not standalone.\n",
    "- It extends an existing property object.\n",
    "- That property object is first created using the @property decorator.\n",
    "\n",
    "#### **What is you skip @property?**  \n",
    "If you try to use @attr.setter without first defining @property, you'll get: \n",
    "```\n",
    "AttributeError: 'function' object has no attribute 'setter'\n",
    "``` \n",
    "Because age was never defined as a @property in the first place, so there's nothing to extend with .setter\n",
    "\n",
    "#### **How does it work internally?**  \n",
    "When you use:\n",
    "```python\n",
    "@property\n",
    "def age(self):\n",
    "    return self._age\n",
    "```\n",
    "You are creating a property object named **age**. That object knows how to get the value.\n",
    "\n",
    "Now you can **extend** that property object to include setter behavior like follows:\n",
    "```python\n",
    "@age.setter\n",
    "def age(self, value):\n",
    "    self._age = value\n",
    "```"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "b88d9cc6-0353-4fb2-8cdb-010a75aae256",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Person:\n",
    "    def __init__(self, age):\n",
    "        self.age = age   # This triggers @age.setter\n",
    "\n",
    "    @property\n",
    "    def age(self):\n",
    "        print(\"Getter called\")\n",
    "        return self._age\n",
    "    \n",
    "    @age.setter\n",
    "    def age(self, value):\n",
    "        print(\"Setter called\")\n",
    "        if value < 0:\n",
    "            raise ValueError(\"Age cannot be negative\")\n",
    "        self._age = value"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "f1233d49-d4ea-4c52-a4a0-917685214b56",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Setter called\n",
      "Getter called\n",
      "25\n",
      "{'_age': 25}\n"
     ]
    }
   ],
   "source": [
    "p = Person(25)     # Call the setter\n",
    "print(p.age)       # Call the getter\n",
    "print(p.__dict__)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "bdedac9c-acc6-42f8-8a95-c962d37ddd52",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Setter called\n",
      "Getter called\n",
      "30\n",
      "{'_age': 30}\n"
     ]
    }
   ],
   "source": [
    "p.age = 30         # Call the setter\n",
    "print(p.age)       # Call the getter\n",
    "print(p.__dict__)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "59165647-5831-4b31-8d09-ede5c89ccb4a",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Setter called\n"
     ]
    },
    {
     "ename": "ValueError",
     "evalue": "Age cannot be negative",
     "output_type": "error",
     "traceback": [
      "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
      "\u001b[0;31mValueError\u001b[0m                                Traceback (most recent call last)",
      "Cell \u001b[0;32mIn[8], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mp\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mage\u001b[49m \u001b[38;5;241m=\u001b[39m \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m1\u001b[39m         \u001b[38;5;66;03m# Call the setter\u001b[39;00m\n\u001b[1;32m      2\u001b[0m \u001b[38;5;28mprint\u001b[39m(p\u001b[38;5;241m.\u001b[39mage)       \u001b[38;5;66;03m# Call the getter\u001b[39;00m\n\u001b[1;32m      3\u001b[0m \u001b[38;5;28mprint\u001b[39m(p\u001b[38;5;241m.\u001b[39m\u001b[38;5;18m__dict__\u001b[39m)\n",
      "Cell \u001b[0;32mIn[5], line 14\u001b[0m, in \u001b[0;36mPerson.age\u001b[0;34m(self, value)\u001b[0m\n\u001b[1;32m     12\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mSetter called\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m     13\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m value \u001b[38;5;241m<\u001b[39m \u001b[38;5;241m0\u001b[39m:\n\u001b[0;32m---> 14\u001b[0m     \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mAge cannot be negative\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m     15\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_age \u001b[38;5;241m=\u001b[39m value\n",
      "\u001b[0;31mValueError\u001b[0m: Age cannot be negative"
     ]
    }
   ],
   "source": [
    "p.age = -1         # Call the setter\n",
    "print(p.age)       # Call the getter\n",
    "print(p.__dict__)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3bacd358-0037-4f16-8732-c6fc2512e523",
   "metadata": {},
   "source": [
    "## **Real Time Example of Encapsulation**\n",
    "\n",
    "Consider the example of a geographical system that needs to deal with coordinates. There is only a certain range of values for which latitude and longitude make sense. Outside of those values, a coordinate cannot exist. We can create an object to represent a coordinate, but in doing so we must ensure that the values for latitude are at all times within the acceptable ranges. And for this, we can use properties:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "617ebc4d-30a7-4e2d-bd1f-cec2f8a6919e",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Coordinate:\n",
    "    def __init__(self, lat: float, long: float) -> None:\n",
    "        self._latitude = self._longitude = None\n",
    "        self.latitude = lat       # This triggers @latitude.setter\n",
    "        self.longitude = long     # This triggers @longitude.setter\n",
    "\n",
    "    @property\n",
    "    def latitude(self) -> float:\n",
    "        return self._latitude\n",
    "\n",
    "    @latitude.setter\n",
    "    def latitude(self, lat_value: float) -> None:\n",
    "        if lat_value not in range(-90, 90 + 1):\n",
    "            raise ValueError(f\"{lat_value} is an invalid value for latitude\")\n",
    "        self._latitude = lat_value\n",
    "\n",
    "    @property \n",
    "    def longitude(self) -> float: \n",
    "        return self._longitude\n",
    "\n",
    "    @longitude.setter\n",
    "    def longitude(self, long_value: float) -> None:\n",
    "        if long_value not in range(-180, 180 + 1):\n",
    "            raise ValueError(f\"{long_value} is an invalid value for longitude\")\n",
    "        self._longitude = long_value"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.9.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
